// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.ui;

import bats.Bats;
import bats.SpatialProperties;
import bats.scene.PickablesNode;
import bats.scene.SceneNode;
import bats.scene.WorldNode;
import com.jme3.animation.AnimChannel;
import com.jme3.animation.AnimControl;
import com.jme3.animation.Bone;
import com.jme3.animation.Skeleton;
import com.jme3.math.Quaternion;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import de.lessvoid.nifty.NiftyEventSubscriber;
import de.lessvoid.nifty.controls.Slider;
import de.lessvoid.nifty.controls.SliderChangedEvent;
import de.lessvoid.nifty.controls.nullobjects.SliderNull;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.MyAnimation;
import jme3utilities.MySkeleton;
import jme3utilities.MyString;
import jme3utilities.debug.SkeletonDebugControl;
import jme3utilities.ui.InputMode;

/**
 * Heads-up display (HUD) extentions for pose mode.
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class PoseHud
        extends HeadsUpDisplay {
    // *************************************************************************
    // constants

    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(PoseHud.class.getName());
    /**
     * list of joint rotation axes
     */
    final private static String poseAxes[] = {
        "x", "y", "z"
    };
    /**
     * items in the "select model" popup menu, including optional ones
     */
    final private static String selectModelItemsAll[] = {
        "bigBob", "bigOto", "bob", "miniBob", "nan", "oto", "sinbad"
    };
    /**
     * items in the "select model" popup menu in basic mode
     */
    final private static String selectModelItemsBasic[] = {
        "bob", "oto"
    };
    // *************************************************************************
    // fields
    /**
     * animation channel (or null for none)
     */
    private AnimChannel channel = null;
    /**
     * original bind pose of the model
     */
    private List<float[]> savedPose = new ArrayList<>();
    /**
     * name of the bone which is selected (or null for none)
     */
    private String selectedBone = null;
    /**
     * name of the model which is selected (or null for none)
     */
    private String selectedModel = null;
    // *************************************************************************
    // constructors

    /**
     * Instantiate a disabled pose-mode HUD for the specified input mode and
     * scene. After instantiation, the first method invoked should be
     * initialize().
     *
     * @param mode which input mode (disabled, not null)
     * @param scene (not null)
     * @param screenId (not null)
     */
    PoseHud(InputMode mode, SceneNode scene, String screenId) {
        super(mode, scene, screenId);
        assert mode.getShortName().equals("pose");
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Copy the items in the "select model" menu.
     *
     * @return a new array
     */
    public static String[] getSelectModelItems() {
        if (Bats.getParameters().allUnits()) {
            return selectModelItemsAll.clone();
        } else {
            return selectModelItemsBasic.clone();
        }
    }

    /**
     * Alter the selected model.
     *
     * @param modelName name of the model to load (not null)
     */
    public void selectModel(String modelName) {
        assert modelName != null;

        Node oldModel = getModel();
        if (oldModel != null) {
            /*
             * Return the old model to its original bind pose.
             */
            restoreBindPose(oldModel);
        }
        /*
         * Determine which indicators are visible.
         */
        boolean skeletonFlag = false;
        boolean targetFlag = false;
        boolean viewpointFlag = false;
        if (oldModel != null) {
            skeletonFlag = SkeletonDebugControl.isDebugEnabled(oldModel);
            targetFlag = SpatialProperties.areTargetsVisible(oldModel);
            viewpointFlag = SpatialProperties.isViewpointVisible(oldModel);
        }
        WorldNode world = scene.getWorld();
        PickablesNode pickables = world.getPickables();
        boolean pickFlag = pickables.countPicked() > 0;
        /*
         * Delete the old model and load the new one.
         */
        pickables.deleteAll();
        loadModel(pickFlag, modelName);
        /*
         * Alter indicator visibility to match the old model.
         */
        world.setTargetsAll(targetFlag);
        world.setViewpointAll(viewpointFlag);
        Node newModel = getModel();
        if (newModel == null) {
            return;
        }
        SkeletonDebugControl.setDebugEnabled(newModel, skeletonFlag);
        /*
         * Save the model's bind pose.
         */
        savePose(newModel);
    }
    // *************************************************************************
    // HeadsUpDisplay methods

    /**
     * Callback which Nifty invokes after the user moves a slider.
     *
     * @param sliderId Nifty element id of the slider (not null)
     * @param event details of the event (not null)
     */
    @NiftyEventSubscriber(pattern = ".*Slider")
    @Override
    public void onSliderChanged(final String sliderId,
            final SliderChangedEvent event) {
        assert sliderId != null;
    }

    /**
     * Process an action which might affect this display.
     *
     * @param words the action string split into an array of words
     * @return true if the action was handled, false if it wasn't handled
     */
    @Override
    boolean processAction(String[] words) {
        String verb = words[0];
        switch (verb) {
            case "select":
                if (words.length == 2) {
                    String what = words[1];
                    switch (what) {
                        case "animation":
                            selectAnimation();
                            return true;
                        case "bone":
                            selectBone();
                            return true;
                        case "model":
                            showPopup(words, getSelectModelItems());
                            return true;
                    }
                } else if (words.length == 3) {
                    String what = words[1];
                    String which = words[2];
                    switch (what) {
                        case "animation":
                            selectAnimation(which);
                            return true;
                        case "bone":
                            selectBone(which);
                            return true;
                        case "model":
                            selectModel(which);
                            return true;
                    }
                }
                break;
        }

        boolean wasHandled = super.processAction(words);
        if (wasHandled) {
            return true;
        }
        /*
         * The action has not been handled yet.
         */
        return false;
    }

    /**
     * Update the user's view of this display.
     */
    @Override
    protected void updateView() {
        super.updateView();
        updateAnimationStatus();
        updateBoneSelection();
        updateModelStatus();
        updatePoseSliders();
    }
    // *************************************************************************
    // private methods

    /**
     * Filter a collection of bone names down to a more manageable size.
     *
     * @param boneNames (altered, not null)
     */
    private void filterBoneNames(Collection<String> boneNames) {
        assert boneNames != null;

        String[] allNames = MyString.toArray(boneNames);
        for (String name : allNames) {
            if (name.endsWith("Lip")
                    || name.startsWith("Brow")
                    || name.startsWith("Cheek")
                    || name.startsWith("IndexFinger")
                    || name.startsWith("Jaw")
                    || name.startsWith("MiddleFinger")
                    || name.startsWith("Pinky")
                    || name.startsWith("RingFinger")
                    || name.startsWith("Thumb")
                    || name.startsWith("Tongue")) {
                boneNames.remove(name);
            }
        }
    }

    /**
     * Access the top node of the model.
     *
     * @return the pre-existing instance or null
     */
    private Node getModel() {
        Spatial spatial = scene.getWorld().getPickables().getOnlyChild();
        Node model = (Node) spatial;
        return model;
    }

    /**
     * Create a standard-sized unit with its base at the origin, facing south,
     * and add it to the scene.
     *
     * @param pickFlag if true, add the unit to the pick; if false the new unit
     * will be unpicked
     * @param modelName name of the model to load (not null)
     */
    private void loadModel(boolean pickFlag, String modelName) {
        assert modelName != null;

        WorldNode world = scene.getWorld();
        selectedModel = modelName;
        switch (modelName) {
            case "bigBob":
                world.getBobs().load(2f, pickFlag, "bob");
                break;
            case "bigOto":
                world.getOtos().load(2f, pickFlag, "oto");
                break;
            case "bob":
                world.getBobs().load(pickFlag, "bob");
                break;
            case "default":
            case "none":
                selectedModel = null;
                break;
            case "miniBob":
                world.getBobs().load(0.5f, pickFlag, "bob");
                break;
            case "nan":
                world.getBobs().load(pickFlag, "nan");
                break;
            case "oto":
                world.getOtos().load(pickFlag, "oto");
                break;
            case "sinbad":
                world.getSinbads().load(pickFlag, "sinbad");
                break;
            default:
                logger.log(Level.SEVERE, "Unknown model {0}!",
                        MyString.quote(modelName));
        }
        selectBone("none");
        if (selectedModel == null) {
            channel = null;
            return;
        }
        Node model = getModel();
        AnimControl control = model.getControl(AnimControl.class);
        channel = control.createChannel();
    }

    /**
     * Restore the saved bind pose of a model.
     *
     * @param spatial which model (not null)
     */
    private void restoreBindPose(Spatial spatial) {
        assert spatial != null;

        Skeleton skeleton = MySkeleton.getSkeleton(spatial);
        int boneCount = skeleton.getBoneCount();
        for (int iBone = 0; iBone < boneCount; iBone++) {
            Bone bone = skeleton.getBone(iBone);
            float[] angles = savedPose.get(iBone);
            for (int iAxis = 0; iAxis < 3; iAxis++) {
                MySkeleton.setAngle(bone, iAxis, angles[iAxis]);
            }
        }
        skeleton.updateWorldVectors();
    }

    /**
     * Save the bind pose of a model.
     *
     * @param spatial which model (not null)
     */
    private void savePose(Spatial spatial) {
        assert spatial != null;

        Skeleton skeleton = MySkeleton.getSkeleton(spatial);
        skeleton.reset();
        savedPose.clear();
        int boneCount = skeleton.getBoneCount();
        for (int iBone = 0; iBone < boneCount; iBone++) {
            Bone bone = skeleton.getBone(iBone);
            Quaternion orientation = bone.getLocalRotation();
            float[] angles = orientation.toAngles(null);
            savedPose.add(angles);
            assert savedPose.get(iBone) == (Object) angles;
        }
    }

    /**
     * Open a popup menu to select an animation.
     */
    private void selectAnimation() {
        Node model = getModel();
        if (model == null) {
            return;
        }
        Collection<String> menuItems = MyAnimation.listAnimations(model);
        menuItems.add("none");
        showPopup("select animation ", menuItems);
    }

    /**
     * Alter which animation is selected.
     */
    private void selectAnimation(String newAnimation) {
        assert newAnimation != null;
        assert channel != null;

        if (newAnimation.equals("none")) {
            MyAnimation.blendTo(channel, null);
            selectBone(selectedBone);
        } else {
            MyAnimation.blendTo(channel, newAnimation);
        }
    }

    /**
     * Open a popup menu to select a single bone.
     */
    private void selectBone() {
        if (channel != null && channel.getAnimationName() != null) {
            return;
        }
        Node model = getModel();
        if (model == null) {
            return;
        }
        Collection<String> boneNames = MySkeleton.listBones(model);
        if (boneNames.isEmpty()) {
            return;
        }
        boneNames.add("none");
        if (boneNames.size() > 30) {
            filterBoneNames(boneNames);
        }
        showPopup("select bone ", boneNames);
    }

    /**
     * Alter which bone is selected.
     *
     * @param newName name of the bone to select (not null)
     */
    private void selectBone(String newName) {
        Node model = getModel();
        if ("none".equals(newName)) {
            selectedBone = null;
        } else {
            selectedBone = newName;
        }
        for (int axis = 0; axis < poseAxes.length; axis++) {
            float angle = 0f; // radians
            if (model != null && selectedBone != null) {
                /*
                 * Match slider to bone angle.
                 */
                angle = MySkeleton.boneAngle(model, selectedBone, axis);
            }
            String axisName = poseAxes[axis];
            setSlider(axisName, angle);
        }
    }

    /**
     * Update the display of animation.
     */
    private void updateAnimationStatus() {
        String animationStatus = "none";
        if (channel != null) {
            String name = channel.getAnimationName();
            if (name != null) {
                animationStatus = name;
            }
        }
        setStatusText("animationStatus", animationStatus);
    }

    /**
     * Update the bone selection status in the display.
     */
    private void updateBoneSelection() {
        String boneStatus = "none";
        if (selectedBone != null) {
            boneStatus = selectedBone;
        }
        setStatusText("boneStatus", boneStatus);
    }

    /**
     * Update the model name in the display.
     */
    private void updateModelStatus() {
        String modelName = "none";
        if (selectedModel != null) {
            modelName = selectedModel;
        }
        setStatusText("modelStatus", modelName);
    }

    /**
     * Update all the pose sliders in the display.
     */
    private void updatePoseSliders() {
        for (int axisIndex = 0; axisIndex < poseAxes.length; axisIndex++) {
            updatePoseSlider(axisIndex);
        }
    }

    /**
     * Update one of the pose sliders in the display.
     *
     * @param axis which axis of rotation (0 = x, 1 = y, 2 = z)
     */
    private void updatePoseSlider(int axisIndex) {
        assert axisIndex >= 0 : axisIndex;
        assert axisIndex < poseAxes.length : axisIndex;

        String axisName = poseAxes[axisIndex];
        String sliderName = axisName + "Slider";
        Slider slider = getScreen().findNiftyControl(sliderName, Slider.class);
        if (slider != null && !(slider instanceof SliderNull)) {
            /*
             * Update the status text to match the slider.
             */
            final float angle = slider.getValue();
            String statusText = String.format("%s = %+4.2f", axisName, angle);
            String statusName = axisName + "SliderStatus";
            setStatusText(statusName, statusText);

            Node model = getModel();
            if (model != null && selectedBone != null
                    && (channel == null
                    || channel.getAnimationName() == null)) {
                /*
                 * Update the joint angle to match the slider.
                 */
                MySkeleton.setBoneAngle(model, selectedBone, axisIndex, angle);
            }
        }
    }
}